ci: run zig build headless, then smoke-test what it installed - #72
Merged
Conversation
The workflows named packages and asserted on paths, which meant every
new package needed a matrix row and every moved file needed a workflow
edit. They now do what a person does on a new machine -- bootstrap,
`zig build headless`, check the result -- and the checking moved into
zig/smoke.zig, where it can import the package lists instead of
repeating them.
The build had to change to make that honest:
- login_shell.zig called std.c.getpwuid from build.zig. On linux the
build runner is not linked against libc, so this was a compile
error for the entire build graph: every `zig build <anything>` on
every linux runner and every container failed identically before
doing any work. macOS links libc unconditionally, which is why it
was the only platform where anything passed. $SHELL answers the
same question without libc.
- login-shell is no longer part of headless. chsh wants a PAM
password and appends to /etc/shells, so a group that claims to
install unattended cannot contain it. It is its own `interactive`
group; `zig build` still runs everything.
zig/smoke.zig asserts against the lists in config.zig,
nvim_plugins.zig and treesitter.zig, so adding a grammar or a plugin
extends the tests for free. It checks that the binaries run, that
every config link resolves, that neovim starts on the real config with
nothing in :messages, that every grammar actually loads, and that fish
sources the shipped conf.d.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Four of the seven images do not ship curl or xz, so four of the seven squares were red by design. A matrix that is expected to be part red is a matrix nobody reads. One job now runs all seven and writes the answer to the step summary, always green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
A passing `zig build` prints nothing, so a green smoke step was indistinguishable from one that did no work; --summary all makes the test count visible. timeout-minutes stops a hung step from running until GitHub's six hour default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
--summary all prints a ninety line green tree and the failing step's own output is interleaved thousands of lines above it, so finding out what broke meant scrolling past every parser that compiled fine. --summary failures prints only the subtree that failed. The 'what landed' find dump went with it: it added sixty lines of lua-language-server paths between the reader and the error, and the smoke tests report what is missing far more precisely. Also corrects the note on the alpine row. zls and lua-ls install fine on musl -- they are tarball extractions. 58 of 59 steps pass there and neovim's own build is the one that does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Both were failing in nvim and nowhere else, but for unrelated reasons, and only one of them is ours: alpine: libuv wants linux/errqueue.h and alpine ships no kernel headers by default. linux-headers joins the setup line -- that is the documented floor growing, which is what the job is for. The row stays experimental because zls and lua-language-server are glibc-only, so the smoke tests should now be the next thing to fail. ubuntu-24.04-arm: nlua0, the bootstrap lua neovim builds to generate its own headers, segfaults on aarch64 linux. Every invocation dies with SIGSEGV in gen_declarations.lua. Nothing in this repo is on that stack, so the row is experimental rather than fixed, and it is kept rather than deleted so it goes green on its own. Both were invisible before: with --summary all the error was interleaved thousands of lines above the summary. Also limits the push trigger to master. With push and pull_request both unfiltered, every commit on a branch with a PR open built the entire matrix twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
alpine passing 8/8 after the linux-headers fix made the gap obvious: every other binary here is a static zig or rust build, so "the file exists" and "the file runs on this libc" are the same claim for them and different claims for lua-language-server, which is the one dynamically linked thing we install. It was the only check that stopped at access(), which is precisely where it mattered least. Running language servers means something can wait on stdin forever if it does not like its arguments, so expectRun now carries a 60s timeout. Verified it fires: a 60s sleep comes back as error.Timeout in 2.6s rather than hanging until the runner gives up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
7 of 8 smoke tests pass on alpine. Everything builds and everything runs except lua-language-server, which fails to spawn: could not run .../bin/lua-language-server: FileNotFound A glibc ELF on musl -- execve cannot find its interpreter. It is the only dynamically linked thing we install, and it is the check that until this morning stopped at access(), which is why nobody had noticed either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Parth
marked this pull request as ready for review
August 24, 2026 17:01
Reverts the split into an 'interactive' group. login-shell belongs in headless; what did not belong was a step that could only run with a human at the keyboard. chsh as an ordinary user asks PAM for a password, which needs a terminal. Under sudo it does not ask, and sudo was already required here to append to /etc/shells, so this asks for nothing it was not already asking for. As root -- which is every container -- there is nothing to elevate and often no sudo to elevate with, so the choice is made at run time by id -u rather than baked in. The /etc/shells check moved from configure time to run time along with it, which also drops isRegistered: grep -qxF answers the same question at the moment it matters instead of one build phase earlier. Verified the script against stubbed chsh/sudo: as root it skips sudo, as a normal user it uses it, a second run does not duplicate the /etc/shells line, and a shell that does not execute fails the step instead of chsh-ing you into a broken login. Containers need chsh to exist for this, which is the documented floor growing again: passwd on debian, util-linux-user on fedora and rocky, shadow on alpine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Drops the grep. Checked what the guard was buying and it was buying
nothing:
- getusershell(), which is what every consumer of /etc/shells goes
through, just enumerates the file. Five duplicate lines iterate as
five entries with no error, and chsh exits 0 against that file.
Callers ask 'is this shell in here', which a repeated line answers
the same way a single one does.
- the append is not what makes the next line work anyway. chsh only
consults /etc/shells for non-root callers, and this one is always
root: setting a shell that appears nowhere in the file succeeds.
It is there so the file stays honest about which shells are login
shells, not as a precondition.
So the worst case is a couple of duplicate lines during the window
before $SHELL flips and the step stops running at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
Same job, more lines, no cleverness. The two moving parts are now named
functions -- bootstrap_in and missing_commands -- the image list is one
per line, and the markdown table is assembled once at the end instead of
being appended to from inside the loop.
Fixes a latent bug while it is in there. The old form was
note="$(printf ... | grep ... | sort -u | paste ...)"
note="needs: ${note:-see logs}"
and that fallback could never fire: GitHub runs the step under
bash -e -o pipefail, so an image that failed for a reason the grep has
no pattern for made the assignment exit 1 and took the whole step with
it. Verified both halves -- the old form exits 1 without printing the
fallback, the new one reports 'unclear, read the log' and carries on.
The output is also plainer, because the command names are now cut out
of the matches rather than shown raw:
before needs: wget: not found xz: Cannot exec
after missing: wget xz
Tested against fixtures reproducing each real image's output, plus an
unrecognised failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U73fQ6rCbAPPf1YRHga95Q
ubuntu-24.04-arm and the alpine headless row were both red on purpose and both blocked on upstream bugs rather than anything here: aarch64: neovim's nlua0 build helper dies because lua_newstate() returns null for the LuaJIT it is built against. Reported upstream as neovim/neovim#40149, open since June with no response. alpine: lua-language-server is the one dynamically linked binary we install and upstream publishes no musl build, so it cannot spawn. Removing them takes continue-on-error and the experimental flag with them, so every remaining square is one that has to be green. alpine still appears under no-system-compiler, which passes. Also cuts the comments back to the ones that answer 'why is this written this way' rather than restating the code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The workflows named packages and asserted on paths, so every new package needed a matrix row and every moved file needed a workflow edit. They now do what a person does on a new machine — bootstrap,
zig build headless, check the result — and the checking lives inzig/smoke.zig, which imports the package lists instead of repeating them.67 + 27 jobs → 5 + 6, and every remaining square has to be green.
The bug that was hiding everything else
login_shell.zigcalledstd.c.getpwuidfrombuild.zig. The build runner is not linked against libc on Linux, so this was a compile error for the entire build graph:Every
zig build <anything>on every Linux runner and all 27 containers died there before doing any work —zig build ripgrepincluded, sincebuild.zigimports every package at comptime. macOS links libc unconditionally, which is why it was the only platform where anything passed, and why the matrix read as "macOS green, Linux red" rather than "one function is wrong".$SHELLanswers the same question without libc.login-shellstayed inheadless, and now runs unattendedchshasks PAM for a password only when run as an ordinary user. It reacheschshas root either way now — viasudo, which was already required to append to/etc/shells, or directly in containers where there is nothing to elevate and often nosudobinary. That choice is made at run time byid -u. The/etc/shellscheck moved to run time too and then disappeared: duplicates are harmless, and the append is not a precondition for anything, since rootchshignores the file entirely.zig/smoke.zigzig build smokeruns eight tests against$DOTFILES_PREFIX, importingconfig.links,nvim_plugins.pluginsandtreesitter.grammarsfrom the packages, so adding a grammar or plugin extends the tests for free:nvim,rg,zls,fish,cargo,lua-language-server— andrust-analyzeris in the toolchaintest -dmisseslibfzf.so(telescope silently degrades without it)E\d+:in:messagesvim.treesitter.language.addeach grammar — compiled and loadable differconf.dEach command has a 60s timeout, since a language server given arguments it dislikes waits on stdin forever.
It is deliberately not a dependency of
headless, so it can re-check an existing install without rebuilding.Platforms dropped
Both were red for upstream reasons, and both are gone rather than carried as permanently-failing squares:
nlua0build helper dies on aarch64 becauselua_newstate()returns null for the LuaJIT it is built against. Filed upstream as neovim/neovim#40149, open since June with no maintainer response. Worth knowing: the same build succeeds when cross-compiled, becausebuild.zig:97(host_use_luajit = if (cross_compiling) false else use_luajit) then backsnlua0with PUC Lua instead. So-Dhost=nativeis a plausible untested workaround if this ever matters.lua-language-serveris the only dynamically linked binary we install and upstream ships no musl build, so it cannot spawn. alpine still appears underno-system-compiler, which passes.Smaller findings
on: pushandon: pull_requestwere both unfiltered.pushis now limited tomaster.--summary allprints a 90-line green tree while the failing step's output streams inline thousands of lines earlier; zig keeps building independent steps after a failure, so the end-of-run summary is the only unambiguous place. Switched to--summary failures, which is how both platform bugs above were finally identified.smokekeeps--summary allbecause it is four lines and turns a silent pass into a test tally.baresquares were red by design — debian/ubuntu ship no curl and no xz, rocky no xz. One green job now runs all seven images in ~47s and writes the answer to the run summary. Its old error-extraction had a latent bug: underbash -e -o pipefailan unrecognised failure killed the step instead of reaching thesee logsfallback.linux/errqueue.h, which alpine does not ship — that was the alpine build failure, not musl incompatibility, andlinux-headersfixed it.timeout-minutes: 60, so a hung step fails instead of running to GitHub's six-hour default.Not done, on purpose
bootstrap.shbuilds a.tar.xzURL unconditionally and zig ships.zipfor Windows; the old workflow carried two permanently-failing rows for it.no-system-compilerstill names a package (zig build treesitter). The one deliberate exception to "don't reach in": it asserts no cc/gcc/clang/make/pkg-config exists and compiles C anyway, which is the load-bearing claim of the repo.neovimandfishare non-lazy dependencies, sozig build ripgrepstill clones all of neovim first. Making them lazy would fix it, but it interacts with the memoisation infish.zigand is not a CI problem.